<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>接口调用监控</title>
  <style>
    :root { color-scheme: light; }
    body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Helvetica Neue", "PingFang SC", "Microsoft YaHei"; margin: 0; }
    header { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; position: sticky; top: 0; background: #fff; z-index: 10; }
    h1 { font-size: 18px; margin: 0; }
    .toolbar { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
    .status { margin-left: auto; font-size: 12px; }
    main { display: grid; grid-template-columns: 1.2fr 1fr; gap: 12px; padding: 12px; height: calc(100vh - 95px); box-sizing: border-box; }
    .pane { border: 1px solid #e5e7eb; border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; }
    .pane header { background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
    .pane header h2 { font-size: 14px; margin: 0; }
    .list { flex: 1; overflow: auto; font-size: 12px; }
    .row { display: grid; grid-template-columns: 120px 60px 1fr 80px 80px; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #f3f4f6; align-items: center; cursor: pointer; }
    .row:hover { background: #f9fafb; }
    .row .method { font-weight: 700; }
    .pill { padding: 2px 6px; border-radius: 10px; font-size: 12px; border: 1px solid #e5e7eb; }
    .ok { color: #065f46; background: #ecfdf5; border-color: #a7f3d0; }
    .err { color: #991b1b; background: #fef2f2; border-color: #fecaca; }
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
    .details { flex: 1; overflow: auto; padding: 12px; }
    pre { background: #0b1020; color: #e5e7eb; padding: 8px; border-radius: 6px; overflow: auto; }
    .controls { display: flex; gap: 8px; align-items: center; }
    button, select { padding: 6px 10px; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; cursor: pointer; }
    input[type="text"] { padding: 6px 10px; border: 1px solid #e5e7eb; border-radius: 6px; width: 260px; }
    .grid-head { display: grid; grid-template-columns: 120px 60px 1fr 80px 80px; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-weight: 600; background: #f9fafb; position: sticky; top: 0; z-index: 5; }
  </style>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
</head>
<body>
  <header>
    <h1>接口调用监控</h1>
    <div class="toolbar">
      <div class="controls">
        <button id="clear">清空</button>
        <label><input type="checkbox" id="autoscroll" checked> 自动滚动</label>
        <select id="methodFilter">
          <option value="">全部方法</option>
          <option>GET</option>
          <option>POST</option>
          <option>PUT</option>
          <option>DELETE</option>
          <option>PATCH</option>
        </select>
        <input type="text" id="urlFilter" placeholder="按 URL 过滤(包含匹配)"/>
      </div>
      <div class="status" id="status">未连接</div>
    </div>
  </header>
  <main>
    <section class="pane">
      <header><h2>历史调用</h2></header>
      <div class="grid-head mono">
        <div>时间</div><div>方法</div><div>URL</div><div>状态</div><div>耗时(ms)</div>
      </div>
      <div class="list" id="list"></div>
    </section>
    <section class="pane">
      <header><h2>详情</h2></header>
      <div class="details" id="details">
        <div class="mono" style="color:#6b7280">选中左侧一行查看详情</div>
      </div>
    </section>
  </main>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
  <script>
    const statusEl = document.getElementById('status');
    const listEl = document.getElementById('list');
    const detailsEl = document.getElementById('details');
    const clearBtn = document.getElementById('clear');
    const autoScrollEl = document.getElementById('autoscroll');
    const methodFilterEl = document.getElementById('methodFilter');
    const urlFilterEl = document.getElementById('urlFilter');

    const state = {
      events: [], // {id, url, method, query, params, body, headers, time} and response pair
      lastById: new Map(),
      selectedId: null,
    };

    function connect() {
      const loc = window.location;
      const protocol = loc.protocol === 'https:' ? 'wss' : 'ws';
      const wsUrl = `${protocol}://${loc.host}/monitor`;
      const ws = new WebSocket(wsUrl);

      ws.onopen = () => { statusEl.textContent = '已连接'; statusEl.style.color = 'green'; };
      ws.onclose = () => { statusEl.textContent = '断开，5s后重连'; statusEl.style.color = 'red'; setTimeout(connect, 5000); };
      ws.onerror = () => { statusEl.textContent = '错误'; statusEl.style.color = 'red'; };
      ws.onmessage = (evt) => {
        try {
          const msg = JSON.parse(evt.data);
          if (msg.type === 'history') {
            for (const e of msg.payload) upsertEvent(e);
            renderList();
            return;
          }
          if (msg.type === 'event') {
            upsertEvent(msg.payload);
            renderList(true);
          }
        } catch (e) { /* ignore */ }
      };
    }

    function upsertEvent(e) {
      const existing = state.lastById.get(e.id);
      if (!existing) {
        state.events.push(e);
        state.lastById.set(e.id, e);
      } else {
        // pair response with request
        state.lastById.set(e.id, { ...existing, ...e });
        const idx = state.events.findIndex(x => x.id === e.id);
        if (idx >= 0) state.events[idx] = state.lastById.get(e.id);
      }
    }

    function renderList(scrollToEnd = false) {
      const methodFilter = methodFilterEl.value;
      const urlFilter = urlFilterEl.value.trim();
      const rows = [];
      for (const e of state.events) {
        if (methodFilter && e.method !== methodFilter) continue;
        if (urlFilter && !(e.url || '').includes(urlFilter)) continue;
        if (e.event === 'request' || e.event === 'response') {
          const status = e.status ?? '-';
          const duration = e.durationMs ?? '-';
          const timeStr = e.time ? new Date(e.time).toLocaleTimeString() : '';
          rows.push(rowHtml(e.id, timeStr, e.method, e.url, status, duration));
        }
      }
      listEl.innerHTML = rows.join('');
      if (autoScrollEl.checked && scrollToEnd) listEl.scrollTop = listEl.scrollHeight;
    }

    function rowHtml(id, time, method, url, status, duration) {
      const pillClass = status === '-' ? '' : (Number(status) < 400 ? 'ok' : 'err');
      return `<div class="row mono" data-id="${id}">
        <div>${time}</div>
        <div class="method">${method || ''}</div>
        <div title="${escapeHtml(url)}">${escapeHtml(url || '')}</div>
        <div><span class="pill ${pillClass}">${status}</span></div>
        <div>${duration}</div>
      </div>`;
    }

    listEl.addEventListener('click', (e) => {
      const row = e.target.closest('.row');
      if (!row) return;
      state.selectedId = row.dataset.id;
      const item = state.lastById.get(state.selectedId);
      renderDetails(item);
    });

    function renderDetails(item) {
      if (!item) { detailsEl.innerHTML = '<div class="mono" style="color:#6b7280">无详情</div>'; return; }
      const { url, method, query, params, body, headers, response, status, durationMs, time } = item;
      detailsEl.innerHTML = `
        <div class="mono"><strong>URL</strong>: ${escapeHtml(url || '')}</div>
        <div class="mono"><strong>方法</strong>: ${escapeHtml(method || '')}</div>
        <div class="mono"><strong>时间</strong>: ${time ? new Date(time).toLocaleString() : ''}</div>
        <div class="mono"><strong>状态</strong>: ${status ?? '-'}</div>
        <div class="mono"><strong>耗时</strong>: ${durationMs ?? '-'} ms</div>
        <h3>入参</h3>
        <pre class="mono"><code class="language-json">${escapeHtml(JSON.stringify({ query, params, body }, null, 2))}</code></pre>
        <h3>请求头</h3>
        <pre class="mono"><code class="language-json">${escapeHtml(JSON.stringify(headers, null, 2))}</code></pre>
        <h3>返回值</h3>
        <pre class="mono"><code class="language-json">${escapeHtml(formatResponse(response))}</code></pre>
      `;
      if (window.hljs) {
        detailsEl.querySelectorAll('pre code').forEach((el) => {
          window.hljs.highlightElement(el);
        });
      }
    }

    function escapeHtml(str) {
      if (str == null) return '';
      return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;');
    }

    function formatResponse(resp) {
      try {
        if (typeof resp === 'string') {
          // 尝试JSON化
          try { return JSON.stringify(JSON.parse(resp), null, 2); } catch { return resp; }
        }
        return JSON.stringify(resp, null, 2);
      } catch { return String(resp); }
    }

    clearBtn.onclick = () => {
      state.events = [];
      state.lastById.clear();
      listEl.innerHTML = '';
      detailsEl.innerHTML = '<div class="mono" style="color:#6b7280">已清空</div>';
    };

    methodFilterEl.onchange = () => renderList();
    urlFilterEl.oninput = () => renderList();

    connect();
  </script>
</body>
</html>


